Day 57 - SwiftUI and SwiftData
Table of Contents
In our last project we looked at using SwiftData with SwiftUI, in this project we’ll go into more detail: we’ll look at things like custom managed object subclasses and ensuring uniqueness.
Today we have three topics where we’ll learn how to organize SwiftData objects with SwiftUI, how to filter your data using #Predicate
, and more.
- SwiftData Introduction
- Editing SwiftData Model Objects
- Filtering
@Query
using Predicate
SwiftData Introduction #
This technical project will explore SwiftData in more detail, starting with a summary of some basic techniques and progressing to tackling more complex problems.
As you can see, SwiftData really pushes the advanced features of both Swift and SwiftUI to make it easier for us to store data efficiently. It’s not always easy though, and there are a few places that require some thought to use it in the right way.
We have a lot to explore, so please create a new project that we can try out. Call it “SwiftDataProject”, not “SwiftData” because that would confuse Xcode.
Make sure you don’t have SwiftData for Stroge enabled. Again, we’ll create this from scratch so you can see how everything works.
Editing SwiftData Model Objects #
SwiftData’s model objects are supported by the same observation system that makes the @Observable
classes work, which means that changes to your model objects will be automatically picked up by SwiftUI, so our data and UI stay in sync.
This support extends to the @Bindable
property wrapper we looked at earlier.
To demonstrate this, we can create a simple User class with a few properties. Create a new file called User.swift, add an import at the top for SwiftData and then add this code;
@Model
class User {
var name: String
var city: String
var joinDate: Date
init(name: String, city: String, joinDate: Date) {
self.name = name
self.city = city
self.joinDate = joinDate
}
}
Now we can create the model container and model context for it by adding another import SwiftData
to the App
struct file and then using modelContainer()
as follows.
WindowGroup {
ContentView()
}
.modelContainer(for: User.self)
When it comes to editing user objects, we create a new view with a name like EditUserView
, then use the @Bindable
property wrapper to create the binding for it. So, something like this;
struct EditUserView: View {
@Bindable var user: User
var body: some View {
Form {
TextField("Name", text: $user.name)
TextField("City", text: $user.city)
DatePicker("Join Date", selection: $user.joinDate)
}
.navigationTitle("Edit User")
.navigationBarTitleDisplayMode(.inline)
}
}
This is the same as how we use a normal @Observable
class, and yet SwiftData continues to automatically write all our changes to persistent storage - this is completely transparent to us.
Add the following code to be able to use Xcode’s Preview;
#Preview {
do {
let config = ModelConfiguration(isStoredInMemoryOnly: true)
let container = try ModelContainer(for: User.self, configurations: config)
let user = User(name: "Taylor Swift", city: "Nashville", joinDate: .now)
return EditUserView(user: user)
.modelContainer(container)
} catch {
return Text("Failed to create container: \(error.localizedDescription)")
}
}
We can make a really simple user editing application out of this by adding a new user at the press of a button and then using programmatic navigation to take the application directly to the new user for editing.
Let’s build this step by step. First open ContentView.swift and add an import for SwiftData, load all User
objects, then store a path that we can bind to a NavigationStack
.
@Environment(\.modelContext)var modelContext
@Query(sort: \User.name)var users: [User]
@Stateprivatevar path = [User]()
body
property’yi aşağıdaki gibi düzenleyin;
NavigationStack(path: $path) {
List(users) { user in
NavigationLink(value: user) {
Text(user.name)
}
}
.navigationTitle("Users")
.navigationDestination(for: User.self) { user in
EditUserView(user: user)
}
}
And now we just need a way to add users. If you think about it, adding and fixing are very similar, so the easiest thing to do here is to create a new User
object with empty properties, add it to the model context and then immediately go to it by setting the path
property.
Add the following two modifiers below the navigation modifier;
.toolbar {
Button("Add User", systemImage: "plus") {
let user = User(name: "", city: "", joinDate: .now)
modelContext.insert(user)
path = [user]
}
}
As you can see, editing with SwiftData objects is no different from editing regular @Observable classes - it’s just an added advantage that all our data is properly loaded and saved.
@Query Filtering using Predicate #
You’ve already seen how @Query
can be used to sort SwiftData objects in a specific order, but this can also be used to filter data.
The syntax of this sounds a bit strange at first, because this is actually another macro behind the scenes. It turns our Swift code into a set of rules that SwiftData can apply to the underlying database that stores all its objects.
Let’s start with something simple, using the User
model we used earlier;
@Model
class User {
var name: String
var city: String
var joinDate: Date
init(name: String, city: String, joinDate: Date) {
self.name = name
self.city = city
self.joinDate = joinDate
}
}
Now we can add a few properties to the ContentView
that can show all the users we have;
@Environment(\.modelContext) var modelContext
@Query(sort: \User.name) var users: [User]
And finally, we can show all these users in a list and we will also add a button to easily add some sample data;
NavigationStack {
List(users) { user in
Text(user.name)
}
.navigationTitle("Users")
.toolbar {
Button("Add Samples", systemImage: "plus") {
let first = User(name: "Ed Sheeran", city: "London", joinDate: .now.addingTimeInterval(86400 * -10))
let second = User(name: "Rosa Diaz", city: "New York", joinDate: .now.addingTimeInterval(86400 * -5))
let third = User(name: "Roy Kent", city: "London", joinDate: .now.addingTimeInterval(86400 * 5))
let fourth = User(name: "Johnny English", city: "London", joinDate: .now.addingTimeInterval(86400 * 10))
modelContext.insert(first)
modelContext.insert(second)
modelContext.insert(third)
modelContext.insert(fourth)
}
}
}
Tip : These join dates represent some number of days in the past or future, which gives us interesting data to work with.
When working with sample data like this, it is useful to be able to delete the existing series before adding the sample data. To do this, add the following code before the let first =
line:
try? modelContext.delete(model: User.self)
This tells SwiftData to delete all existing model objects of type User
, which means that the database is clean before adding sample users.
To complete our little sample application, we need to make sure that the App
struct uses the modelContainer()
modifier to set up SwiftData correctly:
WindowGroup {
ContentView()
}
.modelContainer(for: User.self)
Now go ahead and run the application, then press the + button to add four users.
You can see that they appear in alphabetical order, because that’s what we wanted in our @Query
property.
Now let’s try filtering this data so that we only show users with a capital R in their name. To do this we apply a filter
parameter to @Query
as follows;
@Query(filter: #Predicate<User> { user in
user.name.contains("R")
}, sort: \User.name) var users: [User]
Let’s explain this a little bit;
- The filter starts with
#Predicate<User>
, which means we are writing a predicate. - This predicate gives us a single user instance to check. In practice, this will be called once for each user loaded by SwiftData and we need to return true if this user should be included in the results.
- Our test checks if the user’s name contains the letter R. If it does, the user is included in the results, otherwise it is not.
Now run the code and you will see that both Rosa and Roy appear in our list, but Ed and Johnny’s names are not included because they do not contain a capital R. The contains()
method is case sensitive, so Ed Sheeran, who has a lowercase r, was not included in the results.
This works great for a simple predicate test, but it’s very rare that users really care about capitalization. They usually just want to type a few letters and look for that match anywhere in the results, ignoring uppercase and lowercase letters.
For this purpose, iOS provides us with a separate localizedStandardContains()
method. This also takes a string to search for, but automatically ignores case, so it’s a much better option when you’re trying to filter by user text.
This is what it looks like;
@Query(filter: #Predicate<User> { user in
user.name.localizedStandardContains("R")
}, sort: \User.name) var users: [User]
In our small test data, this means that we will see three out of four users, because these three have the letter “r” somewhere in their name.
Now let’s go one step further: Let’s increase our filter to match people with the letter “R” in their name who live in London;
@Query(filter: #Predicate<User> { user in
user.name.localizedStandardContains("R") &&
user.city == "London"
}, sort: \User.name) var users: [User]
This uses Swift’s “logical and” operator. In this case, results with the letter r in the name and living in London are filtered out.
You can add more checks like this, but using &&
is a bit confusing. Fortunately, these predicates support a limited subset of Swift expressions, which makes them a little easier to read.
For example, we can rewrite the existing predicate as follows;
@Query(filter: #Predicate<User> { user in
if user.name.localizedStandardContains("R") {
if user.city == "London" {
return true
} else {
return false
}
} else {
return false
}
}, sort: \User.name) var users: [User]
Now, you might be thinking that this is a bit verbose. You can remove both else
blocks and just end with return true
, because if the user actually matches the predicate, return true
will already be provided.
This is how it will look like;
@Query(filter: #Predicate<User> { user in
if user.name.localizedStandardContains("R") {
if user.city == "London" {
return true
}
}
return false
}, sort: \User.name) var users: [User]
Unfortunately this code isn’t actually valid, because it’s important to remember that even though it looks like we’re running pure Swift code, it’s not actually happening. The #Predicate
macro actually rewrites our code to be a set of tests that it can apply to the database that doesn’t use Swift internally.
To see what happens internally, undo the changes (⌘ + Z) . Now right click on #Predicate
and select Expand Macro and you will see a large amount of code appear. Remember, this is the actual code that will be generated and executed.
You can also read this article in Turkish.
Bu yazıyı Türkçe olarak da okuyabilirsiniz.